require 'jinx/helpers/hasher'
require 'jinx/helpers/inflector'
require 'jinx/helpers/validation'
require 'catissue/helpers/storable'
require 'catissue/helpers/collectible'

module CaTissue
  # The Specimen domain class.
  class Specimen
    include Collectible, Storable
    
    # @quirk caTissue Bug #64: Some domain collection properties not initialized.
    #   Initialize consent_tier_statuses if necessary.
    #
    # @return [Java::JavaUtil::Set] the statuses
    def consent_tier_statuses
      getConsentTierStatusCollection or (self.consent_tier_statuses = Java::JavaUtil::LinkedHashSet.new)
    end

    # Sets the barcode to the given value. This method converts an Integer to a String.
    def barcode=(value)
      value = value.to_s if Integer === value
      setBarcode(value)
    end

    add_attribute_aliases(:requirement => :specimen_requirement, :position => :specimen_position)

    add_attribute_defaults(:activity_status => 'Active', :collection_status => 'Collected')

    add_mandatory_attributes(:specimen_collection_group, :initial_quantity, :available_quantity)

    set_secondary_key_attributes(:label)
    
    set_alternate_key_attributes(:barcode)

    # Specimen children are constrained to Specimen.
    set_attribute_type(:child_specimens, Specimen)

    # Specimen parent is constrained to Specimen.
    set_attribute_type(:parent_specimen, Specimen)

    # Even though the inverse is declared in AbstractSpecimen, do so again here to
    # ensure that there is no order dependency of the dependent declaration below
    # on AbstractSpecimen metadata initialization.
    set_attribute_inverse(:parent_specimen, :child_specimens)
    
    # A child Specimen is auto-generated from a SpecimenRequirement template if it
    # is part of a hierarchy built by SCG create. Unlike SpecimenRequirement,
    # Specimen children are cascaded.
    qualify_attribute(:child_specimens, :autogenerated)

    # @quirk caTissue Specimen consent_tier_statuses is cascaded but not fetched.
    #
    # @quirk caTissue 2.0 a Specimen consent change is propagated in the UI to children
    #   but not grandchildren. The change is not propagated to the children in the
    #   API (cf. https://cabig-kc.nci.nih.gov/Biospecimen/forums/viewforum.php?f=19).
    #   This behavior does not occur in 1.2.
    #
    # @quirk caTissue When a Specimen is auto-generated or created, a consent tier
    #   status is created for each registration consent. A created Specimen has a
    #   a well-formed status record. An auto-generated status record is malformed,
    #   missing the Specimen id reference. This differs from the auto-generated
    #   SCG status record, which is malformed in a different way which results in
    #   a severe display error, as described in Bug #156. The malformed Specimen
    #   status record results in displaying null field values in the Specimen Consent
    #   page, but otherwise has no adverse effects.
    add_dependent_attribute(:consent_tier_statuses, :unfetched)

    add_dependent_attribute(:external_identifiers)

    # @quirk caTissue Specimen position update is cascaded in Hibernate, but updateObject
    #   is precluded by the caTissue business logic. Position change is performed by a
    #   TransferEventParameters proxy instead. SpecimenPosition work-around is to
    #   designate a save proxy.
    add_dependent_attribute(:specimen_position)
    
    # @quirk caTissue Even though the Specimen SCG is not fetched and cannot be changed,
    # Specimen update requires that the Specimen reference an SCG.
    qualify_attribute(:specimen_collection_group, :include_in_save_template)

    # Although label is the secondary key, it is auto-generated if not provided in the create.
    qualify_attribute(:label, :optional)
    
    # Although barcode is the alternate key, it is auto-generated if not provided in the create.
    qualify_attribute(:barcode, :optional)

    # The Specimen-SpecimenRequirement association is bi-directional.
    set_attribute_inverse(:specimen_requirement, :specimens)
    
    # @quirk caTissue The Specimen save argument created_on flag is ignored and set by caTissue instead.
    qualify_attribute(:created_on, :volatile)

    # Oddly, the seldom-used biohazards are fetched along with Specimen.
    qualify_attribute(:biohazards, :fetched)

    # @quirk caTissue the Specimen parent_changed flag is ignored in a caCORE update
    # or create Specimen argument and is set on the server. Mark the attribute as unsaved.
    qualify_attribute(:parent_changed, :unsaved)

    # @quirk caTissue Bug #159: caTissue update of a pending Specimen ignores the available
    #   quantity in the update argument. The available quantity is not accurately reflected
    #   in the caCORE create or update result. This is true even though the caTissue GUI
    #   supports available_ uantity update. The caRuby work-around is to set the
    #   +:autogenerated+ flag, which will refetch a saved Specimen if there is a change
    #   to the available quantity value and will reupdate the Specimen if the fetched
    #   available quantity differs from the save argument value.
    #
    # @quirk caTissue caTissue sporadically updates the available quantity when the
    #   initial quantity is changed as follows:
    #
    #   * Precondition: Available Quantity is less than the Initial Quantity
    #     Action: User sets Available Quantity to the Initial Quantity
    #     Result: Available Quantity is unchanged
    #
    #   * Action: User increases Initial Quantity
    #     Result: Available Quantity is incremented by the same amount
    #
    #   * Action: User decreases Initial Quantity
    #     Result: Available Quantity is unchanged
    #
    #   The caRuby work-around is to mark available quantity as +volatile+, implying that
    #   all bets are off as to how a saved available quantity is reflected in the database.
    qualify_attribute(:available_quantity, :autogenerated, :volatile)

    # @quirk caTissue An auto-generated Specimen auto-generates the collection and received events.
    #   Therefore, CPR create must match the complete auto-generated SCG-Specimen-Event hierarchy.
    #   However, SpecimenRequirement events are not auto-generated. Therefore, the AbstractSpecimen
    #   events behavior must be specialized for Specimen but not SpecimenRequirement.
    qualify_attribute(:specimen_event_parameters, :autogenerated, :fetch_saved)
    
    # The Specimen pathology annotation.
    add_annotation('Pathology', :package => 'pathology_specimen', :service => 'pathologySpecimen')
    
    # The preferred owner attribute evaluation order is the parent specimen, then the SCG.
    order_owner_attributes(:parent_specimen, :specimen_collection_group)
    
    # Specimen storage is constrained on the basis of the +specimen_class+.
    alias :storable_type :specimen_class

    def initialize
      super
      # work around caTissue Bug #64
      self.consent_tier_statuses ||= Java::JavaUtil::LinkedHashSet.new
    end
    
    # @return [Boolean] whether this Specimen collection status is +Pending+
    def pending?
      collection_status == 'Pending'
    end
    
    # @return [Boolean] whether this Specimen collection status is +Collected+
    def collected?
      collection_status == 'Collected'
    end

    # Relaxes the +CaRuby::Persistable.fetch_saved?+ condition for a Specimen as follows:
    # * If the Specimen available_quantity was updated, then fetch the saved Specimen.
    # 
    # @return (see CaRuby::Persistable#fetch_saved)
    def fetch_saved?
      super and available_quantity_changed?
    end
    
    # @return [CaTissue::CheckInCheckOutEventParameter] a new check-out parameter
    def check_out
      CaTissue::CheckInCheckOutEventParameter.new(:specimen => self, :storage_status => Status.CHECKED_OUT)
    end
    
    # @return [CaTissue::CheckInCheckOutEventParameter] a new check-in parameter
    def check_in
      CaTissue::CheckInCheckOutEventParameter.new(:specimen => self, :storage_status => Status.CHECKED_IN)
    end
    
    # Overrides +CaRuby::Mergable.merge_attribute+ to work around the caTissue
    #  bugs described in {CaTissue::Specimen.remove_phantom_external_identifier}.
    def merge_attribute(attribute, newval, matches=nil)
      if attribute == :external_identifiers and newval then
        CaTissue::Specimen.remove_phantom_external_identifier(newval)
      end
      super
    end

#    # Restores this disposed Specimen by deleting the DisposalEventParameters and resetting the availability and activity status.
#    # Returns the deleted DisposalEventParameters, or nil if none.
#    def recover
#      # TODO - test this
#      dep = event_parameters.detect { |ep| CaTissue::DisposalEventParameters === ep }
#      return if dep.nil?
#      dep.delete
#      self.available = true
#      self.activity_status = 'Active'
#      update
#    end

    # Override default +Jinx::Resource.merge_attributes+ to ignore a source SpecimenRequirement parent_specimen.
    def merge_attributes(other, attributes=nil, matches=nil, &filter)
      case other
      when SpecimenRequirement then
        # merge with the default requirement merge attributes if necessary
        attributes ||= MERGEABLE_RQMT_ATTRS
        super(other, attributes)
        # copy the requirement characteristics
        sc = other.specimen_characteristics
        self.specimen_characteristics ||= sc.copy(MERGEABLE_SPC_CHR_ATTRS) if sc
      when Jinx::Hasher then
        # the requirement template
        rqmt = other[:specimen_requirement] || other[:requirement]
        # merge the attribute => value hash
        super
        # merge the SpecimenRequirement after the hash
        merge_attributes(rqmt) if rqmt
      else super
      end
      self
    end

    # Returns the Specimen in others which matches this Specimen in the scope of an owner SCG.
    # This method relaxes +Jinx::Resource.match_in_owner_scope+ to include a match on at least
    # one external identifier.
    def match_in_owner_scope(others)
      super or others.detect do |other|
        other.class == self.class and external_identifier_match?(other)
      end
    end

    # Augments {AbstractSpecimen#minimal_match?} with an additional restriction that the other
    # specimen is in pending state. This ensures that a specimen submitted for create matches
    # its auto-generated counterpart but a new specimen can be created even if it matches an
    # existing specimen on the features described in {AbstractSpecimen#minimal_match?}. 
    # 
    # @param (see Jinx::Resource#minimal_match?)
    # @return (see Jinx::Resource#minimal_match?)
    def minimal_match?(other)
      super and other.collection_status == 'Pending'
    end
    
    # @return the SpecimenPosition class which this Specimen's Storable can occupy
    def position_class
      CaTissue::SpecimenPosition
    end

    # @return this Specimen +position+ Location
    def location
      position.location if position
    end
    
    # Creates a new Specimen or CaTissue::SpecimenRequirement from the given symbol => value
    # parameter hash.
    #
    # The default class is inferred from the +:class+ parameter, if given, or inherited
    # from this parent specimen otherwise. The inferred class is the camel-case parameter value
    # with +Specimen+ appended, e.g. +:tissue+ => +TissueSpecimen+. This class name is resolved to
    # a class in the CaTissue module context.
    #
    # The supported +:type+ parameter value includes the permissible caTissue specimen type String
    # values as well as the shortcut tissue type symbols :fresh, :fixed and +:frozen+.
    #
    # If a SpecimenRequirement parameter is provided, then that SpecimenRequirement's attribute
    # values are merged into the new Specimen after the other parameters are merged. Thus, params
    # takes precedence over the SpecimenRequirement.
    #
    # If the +:count+ parameter is set to a number greater than one, then the specimen is aliquoted
    # into the specified number of samples.
    #
    # This method is a convenience method to create either a Specimen or CaTissue::SpecimenRequirement.
    # Although CaTissue::SpecimenRequirement is a direct CaTissue::AbstractSpecimen subclass rather than
    # a Specimen subclass, the create functionality overlaps and Specimen is the friendlier
    # class to define this utility method as opposed to the more obscure CaTissue::AbstractSpecimen.
    #
    # @param [<{Symbol => Object}>] params the create parameter hash. Besides the listed params options,
    #   this hash can include additional target Specimen attribute => value entries for any supported
    #   Specimen Java property attribute
    # @option params [Symbol, String, Class] :class the required target specimen class,
    #   e.g. :molecular, +TissueSpecimen+ or CaTissue::TissueSpecimen
    # @option params [Symbol, String, Class] :type the optional target specimen type symbol or string,
    #   e.g. :frozen or +Frozen Tissue Block+
    # @option params [Numeric] :quantity the optional target specimen intial quantity
    # @option params [CaTissue::SpecimenRequirement] :requirement the optional requirement with additional
    #   target attribute values
    # @raise [ArgumentError] if the specimen class option is not recognized
    def self.create_specimen(params)
      raise ArgumentError.new("Specimen create params argument type unsupported: #{params.class}") unless Hash === params
      # standardize the class, type and quantity params
      spc_cls = params.delete(:class)
      params[:specimen_class] ||= spc_cls if spc_cls
      spc_type = params.delete(:type)
      params[:specimen_type] ||= spc_type if spc_type
      qty = params.delete(:quantity)
      params[:initial_quantity] ||= qty if qty

      # the specimen_class as a Class, Symbol or String
      cls_opt = params[:specimen_class]
      # standardize the specimen_class parameter as a permissible caTissue value
      standardize_class_parameter(params)
      # if the specimen_class was not specified as a Class, then infer the specimen domain class from the
      # parameter prefix and Specimen suffix
      if Class === cls_opt then
        klass = cls_opt
      else
        class_name = params[:specimen_class] + 'Specimen'
        klass = CaTissue.const_get(class_name)
        raise ArgumentError.new("Specimen class #{class_name} is not recognized for parameter #{cls_opt}") if klass.nil?
      end
      
      # add a default available quantity to a Specimen but not a SpecimenRequirement
      params[:available_quantity] ||= params[:initial_quantity] if klass <= self
      # gather the characteristic parameters
      chr_params, spc_params = params.split { |pa, value| CaTissue::SpecimenCharacteristics.property_defined?(pa) }
      unless chr_params.empty? then
        spc_params[:specimen_characteristics] = CaTissue::SpecimenCharacteristics.new(chr_params)
      end  
      # make the specimen
      klass.new(spc_params)
    end

    # Convenience method which returns the SCG collection protocol.
    #
    # @return [CaTissue::CollectionProtocol] the SCG collection protocol
    def collection_protocol
      specimen_collection_group.collection_protocol
    end

    # Withdraws consent for this Specimen as follows:
    # * If a consent tier is provided, then the SCG {CaTissue::ConsentTierStatus} with the
    #   given consent tier is withdrawn.
    # * Otherwise, all SCG consents are withdrawn.
    #
    # @param [CaTissue::ConsentTier, nil] consent_tier the optional consent tier to withdraw
    # @raise [Jinx::ValidationError] if there is no SCG consent status for the given consent tier
    def withdraw_consent(consent_tier=nil)
      if consent_tier.nil? then
        return specimen_collection_group.consent_tier_statuses.each { |cts| withdraw_consent(cts.consent_tier) }
      end
      tgt = consent_tier_statuses.detect { |cts| cts.consent_tier.matches?(consent_tier) }
      if tgt.nil? then
        consent_tier_statuses << tgt = ConsentTierStatus.new(:consent_tier => consent_tier)
      end
      tgt.status = 'Withdrawn'
    end
    
    # @return [Boolean] whether this specimen includes a {CaTissue::DisposalEventParameters}
    def disposed?
      specimen_event_parameters.any? { |ep| CaTissue::DisposalEventParameters === ep }
    end
    
    # Permanently dispose of this specimen.
    #
    # @param [String, nil] reason the optional disposal reason
    # @return [CaTissue::DisposalEventParameters] the disposal event
    def dispose(reason=nil)
      CaTissue::DisposalEventParameters.new(:specimen => self, :reason => reason)
    end
    
    protected

    # @quirk caTissue Specimen auto-generates a phantom ExternalIdentifier.
    #   cf. https://cabig-kc.nci.nih.gov/Biospecimen/forums/viewtopic.php?f=19&t=436&sid=ef98f502fc0ab242781b7759a0eaff36
    #
    # @param [<CaTissue::ExternalIdentifier>] eids the ExternalIndentifiers to clean up
    def self.remove_phantom_external_identifier(eids)
      phantom = eids.detect { |eid| eid.name.nil? }
      if phantom then
        logger.debug { "Work around caTissue bug by removing the phantom fetched #{phantom.specimen.qp} #{phantom.qp} from #{eids.qp}..." }
        # remove the phantom eid
        eids.delete(phantom)
      end
      eids
    end

    private

    MERGEABLE_RQMT_ATTRS = nondomain_java_attributes - primary_key_attributes

    MERGEABLE_SPC_CHR_ATTRS = SpecimenCharacteristics.nondomain_java_attributes - SpecimenCharacteristics.primary_key_attributes
    
    # Validates that the following conditions hold:
    # * a top-level Specimen does not have a SGC
    # * the available_quantity exceeds the initial_quantity
    # * the availability flag is set and the available_quantity is zero
    #
    # @quirk caTissue Bug #160: Missing Is Available? validation.
    # Updating Specimen with the availablity flag set and available_quantity zero
    # silently leaves the availablity flag unset.
    #
    #  @raise [Jinx::ValidationError] if the validation fails
    def validate_local
      super
      if parent.nil? and specimen_collection_group.nil? then
        raise Jinx::ValidationError.new("Top-level specimen #{self} is missing specimen collection group")
      end
      if available_quantity and initial_quantity and available_quantity > initial_quantity then
        raise Jinx::ValidationError.new("#{self} available quantity #{available_quantity} cannot exceed the initial quantity #{initial_quantity}")
      end
      if available? and (initial_quantity.zero? or available_quantity.zero?) then
        raise Jinx::ValidationError.new("#{self} availablility flag cannot be set if the available quantity is zero")
      end
      if collected? then
        unless event_parameters.detect { |ep| CaTissue::CollectionEventParameters === ep } then
          raise Jinx::ValidationError.new("#{self} is missing CollectionEventParameters")
        end
        unless event_parameters.detect { |ep| CaTissue::ReceivedEventParameters === ep } then
          raise Jinx::ValidationError.new("#{self} is missing ReceivedEventParameters")
        end
      end
    end
    
    # @param [Resource] other the object to match
    # @return [Boolean] whether this specimen matches the other specimen on at least one external identifier
    def external_identifier_match?(other)
       external_identifiers.any? do |eid|
         other.external_identifiers.detect { |oeid| eid.name == oeid.name and eid.value == oeid.value }
      end
    end
     
    # @see #fetch_saved
    def available_quantity_changed?
      oldval = snapshot && snapshot[:available_quantity]
      newval = available_quantity
      if oldval and oldval != newval then
        logger.debug { "Saved #{qp} must be fetched from the database to reflect the current database state, since the available quantity was changed from #{oldval} to #{newval}." }
        true
      else
        false
      end
    end
    
    def format_saved_fetch_message(fetch, reason)
      args = fetch ? [qp, 'must', reason] : [qp, 'does not need to', reason]
      SAVED_FETCH_MSG % args
    end
    
    # Adds this Specimen's defaults, as follows:
    # * The default specimen_collection_group is the parent specimen_collection_group.
    # * Add default collection and received event parameters if this Specimen is collected.
    # * If the is_available flag is set to false then the default available quantity is
    #   zero, otherwise the default available quantity is the initial quantity.
    # * The default is_available flag is true if the available quantity is greater than zero.
    #
    # The motivation for the is_available flag default is that an initial quantity of zero can indicate
    # unknown amount as well a known zero amount, and therefore the available flag should be set to
    # false only if it is known that there was an amount but that amount is exhausted.
    #
    # @quirk caTissue initial_quantity cannot be null (cf. Bug #160).
    #
    # @quirk caTissue the available status cannot be set to to false. The status must be set to nil
    # instead. caTissue allows a nil available status on insert but not a false value, even though a
    # nil status is set to false (0 database value) when the record is inserted.
    #
    # @quirk caTissue a collected Specimen without a collection and received event parameters
    # results in the dreaded 'Severe Error' caTissue server message. Create default SEPs if necessary.
    def add_defaults_local
      super
      self.specimen_collection_group ||= parent.specimen_collection_group if parent
      add_default_event_parameters if collected?
      
      # The default available quantity is the initial quantity.
      self.available_quantity ||= is_available ? initial_quantity : 0
      
      if is_available.nil? then
        self.is_available = default_availablility
      elsif is_available == false then
        # Reset is_available value from false to nil to work around caTissue bug but described in
        # the method rubydoc above.
        self.is_available = nil
      end
    end
    
    # The specimen is available by default if there is a positive available quantity.
    #
    # @return [Boolean, nil] +nil+ if the available quantity is zero, +true+ otherwise
    def default_availablility
      available_quantity.zero? ? nil : true
    end
    
    # Adds default collection and received event parameters.
    #
    # The specimen receiver is the SCG receiver.
    # The specimen collector is the SCG collector, if it exists, otherwise the receiver.
    def add_default_event_parameters
      rep = received_event_parameters || create_default_received_event_parameters || return
      if collection_event_parameters.nil? then
        create_default_collection_event_parameters(rep)
      end
    end
    
    # @return [CaTissue::ReceivedEventParameters] the default REP
    def create_default_received_event_parameters
      rep = parent.received_event_parameters if parent
      user = rep.user if rep
      user ||= scg_receiver || return
      rep = CaTissue::ReceivedEventParameters.new(:specimen => self, :user => user)
      logger.debug { "Created default #{qp} received event #{rep.qp}." }
      rep
    end
    
    # The default CEP user is, by order of preference:
    # * this specimen's parent specimen collector
    # * this specimen's SCG collector
    # * the receiver
    # 
    # @param [CaTissue::ReceivedEventParameters] the required REP
    # @return [CaTissue::CollectionEventParameters] the default CEP
    def create_default_collection_event_parameters(rep)
      cep = parent.collection_event_parameters if parent
      user = cep.user if cep
      user ||= scg_collector || rep.user || return
      cep = CaTissue::CollectionEventParameters.new(:specimen => self, :user => user)
      logger.debug { "Created default #{qp} collection event #{cep.qp}." }
      cep
    end
    
    # @return [CaTissue::User, nil] the SCG receiver, if any
    def scg_receiver
      scg = specimen_collection_group || return
      scg.receiver or default_scg_coordinator(scg)
    end
    
    # @return [CaTissue::User, nil] the SCG collector, if any
    def scg_collector
      specimen_collection_group.collector if specimen_collection_group
    end
    
    # @return [CaTissue::User, nil] the first SCG protocol coordinator
    def default_scg_coordinator(scg)
      cp = scg.collection_protocol || return
      cp.coordinators.first
    end

    # Sets the +:specimen_class+ parameter to a permissible caTissue value.
    def self.standardize_class_parameter(params)
      opt = params[:specimen_class]
      if opt.nil? then
        rqmt = params[:specimen_requirement] || params[:requirement]
        if rqmt then
          rqmt.add_defaults unless rqmt.specimen_class
          opt = rqmt.specimen_class
        end
      end
      raise ArgumentError.new("Specimen class is missing from the create parameters") if opt.nil?
      # Convert the class option Symbol to a capitalized String without a class path prefix or the
      # Specimen[Requirement] suffix.
      params[:specimen_class] = opt.to_s[/(\w+?)(Specimen(Requirement)?)?$/, 1].capitalize_first
    end

    def set_aliquot_parameters(params, count)
      super
      # default available quantity
      self.available_quantity ||= initial_quantity
      # apportion the parent quantity
      params[:initial_quantity] ||= available_quantity / count
    end

    # Delegate to {AbstractSpecimen#create_derived} and add a default label if necessary. The default label
    # is this Specimen label appended with an underscore and the number of children, e.g. +TB-0023434_1+
    # for the first child of a parent with label +TB-0023434+.
    def create_derived(params)
      spc = super
      spc.label ||= "#{label}_#{children.size}" if label
      spc.specimen_collection_group = specimen_collection_group
      # if the derived specimen is the same type as this parent specimen,
      # then decrement this parent's quantity by the derived specimen amount
      decrement_derived_quantity(spc) if specimen_type == spc.specimen_type
      spc
    end

    def self.specimen_class_symbol_to_class(symbol)
      name = symbol.to_s
      class_name = name[0, 1].upcase + name[1..-1]
      suffix = 'Specimen'
      class_name << suffix unless class_name.rindex(suffix) == class_name.length - suffix.length
      begin
        CaTissue.const_get(class_name)
      rescue NameError
        raise ArgumentError.new("Specimen class #{class_name} is not recognized for specimen type parameter #{symbol} - #{$!}")
      end
    end

    # Decrements this parent's available quantity by the given child's initial quantity, if the specimen types are the same and there
    # are the relevant quantities.
    def decrement_derived_quantity(child)
      return unless specimen_type == child.specimen_type and child.initial_quantity
      if available_quantity.nil? then
        raise Jinx::ValidationError.new("Derived specimen has an initial quantity #{child.initial_quantity} but the parent is missing an available quantity")
      elsif (available_quantity - child.initial_quantity).abs < 0.00000001 then
        # rounding error
        self.available_quantity = 0.0
      elsif child.initial_quantity <= available_quantity then
        self.available_quantity -= child.initial_quantity
      else
        raise Jinx::ValidationError.new("Derived specimen initial quantity #{child.initial_quantity} exceeds parent available quantity #{available_quantity}")
      end
    end
  end
end
